Please enable JavaScript to view this website.

Skip to main content

Firmware Update Jobs

This is the device-side implementation guide for the direct-to-cloud delivery channel: OTA updates for devices that hold their own MQTT connection to the platform. The device discovers a firmware update as an AWS IoT Job, downloads the image over mTLS using its operational certificate, verifies the bytes against the job document, and reports the outcome back via the Jobs API. Tools without their own cloud connection will be served by relayed delivery, which is not yet built.

The discovery and claiming mechanics are identical to platform-triggered certificate rotation: same topics, same dispatch-on-operation pattern, same executionNumber bookkeeping. If you have implemented rotation jobs, most of this page will look familiar; the new material is the job document contract and the mTLS download.

Prerequisites

  • The device is fully provisioned and connected with an active operational certificate. The default device policy already grants access to $aws/things/{MPBID}/jobs/*; no extra policy setup is needed for OTA firmware update jobs.
  • The operational certificate doubles as the mTLS client identity for the firmware download. A device whose operational certificate is expired or revoked must rotate before it can download firmware.

Job Document Format

{
"operation": "firmware-update",
"packageId": "0x012F#main-mcu#1.3.2#000007",
"productId": "0x012F",
"firmwarePart": "xxxxxxxx",
"version": "1.3.2",
"revision": 7,
"downloadUrl": "https://firmware.{stage}.iot.digital.milwaukeetool.com/jedi-fw/0x012F/main-mcu/1.3.2/fw-1.3.2-r7.bin",
"checksum": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"size": 1234567,
"deliveryMode": "automatic",
"schedule": null,
"rolloutId": "6f1c2f6e-8a4b-4f0e-9a3d-1c2b3a4d5e6f",
"hardwareVersion": null,
"bootloaderVersion": null,
"bootloaderPart": null,
"firmwareMatchSet": null,
"compatibleProductIds": null,
"updateType": null,
"isCritical": null,
"cveIdentifiers": null,
"severity": null,
"signature": null,
"signatureAlgorithm": null
}
FieldTypeNotes
operationstringAlways the literal firmware-update. Dispatch on this field; skip jobs with any other value (Handling Multiple Job Types).
packageIdstringproductId#firmwarePart#version#revision with the revision zero-padded to 6 digits. Useful for logging.
productIdstring4-digit hex product identifier (e.g. 0x012F).
firmwarePartstringWhich firmware image on the device this update targets (e.g. xxxxxxxx).
versionstringSemver of the new image.
revisionintegerBuild counter for this productId + firmwarePart.
downloadUrlstringFull HTTPS URL of the image on the firmware distribution endpoint. Not a presigned URL; it does not expire and can be re-fetched.
checksumstringSHA-256 of the image as bare 64-character lowercase hex. No sha256: prefix.
sizeintegerExact image size in bytes.
deliveryModestringAlways automatic today: act on the job as soon as it is discovered.
scheduleobject | nullAlways null today. Reserved for scheduled delivery.
rolloutIdstringThe rollout this job belongs to. Equals the IoT job ID today, but named rolloutId because the same document will later travel over BLE where no job exists.
hardwareVersion, bootloaderVersion, bootloaderPart, firmwareMatchSet, compatibleProductIdsvaries | nullCompatibility metadata, passed through when the package defines it. The platform does not filter on these; the device decides compatibility and reports FAILED if the image does not apply.
updateType, isCritical, cveIdentifiers, severity, signature, signatureAlgorithmvaries | nullReserved security/CRA metadata. Always null today.

Parsing rules

  • Tolerate null and absent fields. Reserved fields arrive as explicit null, not false or []. There is deliberately no installInstructions field: how to flash, stage, and reboot is the device's own policy.
  • Ignore unknown fields. New fields will be added over time; parse tolerantly, the same way as the config shadow.
  • Validate before claiming. Check that downloadUrl, checksum, size, version, and rolloutId are present and well-formed before reporting IN_PROGRESS. If the document is invalid, report FAILED with reasonCode invalid-job-document and do not attempt the download.

MQTT Topics

The standard AWS IoT Jobs topic set, identical to certificate rotation:

TopicDirectionPurpose
$aws/things/{MPBID}/jobs/notify-nextSubscribeNotified when a new pending job becomes available
$aws/things/{MPBID}/jobs/getPublishGet list of all pending job IDs (without starting any)
$aws/things/{MPBID}/jobs/get/acceptedSubscribeReceive pending job list
$aws/things/{MPBID}/jobs/get/rejectedSubscribeError if request malformed
$aws/things/{MPBID}/jobs/{jobId}/getPublishDescribe a specific job and retrieve its document
$aws/things/{MPBID}/jobs/{jobId}/get/acceptedSubscribeReceive job document
$aws/things/{MPBID}/jobs/{jobId}/get/rejectedSubscribeError if job not found
$aws/things/{MPBID}/jobs/{jobId}/updatePublishReport job status (IN_PROGRESS, SUCCEEDED, FAILED)
$aws/things/{MPBID}/jobs/{jobId}/update/acceptedSubscribeConfirmation of status update
$aws/things/{MPBID}/jobs/{jobId}/update/rejectedSubscribeError if status update rejected

As with rotation jobs, do not use StartNextPendingJobExecution: it blindly claims whichever job is first in the queue, which may be a job type this handler cannot complete. List with GetPendingJobExecutions, inspect each document with DescribeJobExecution, and only claim the job whose operation is firmware-update.

A queued job can also disappear before the device claims it: when a newer rollout supersedes an older one, the platform cancels the older queued execution. Treat a rejected response on a job that was just listed as normal, re-list, and move on.

Update Sequence

Downloading over mTLS

The downloadUrl host is firmware.<stage>.iot.digital.milwaukeetool.com. It is a single global endpoint per stage (not regional), and it requires a client certificate on the TLS handshake. Present the device's operational certificate and private key as the client identity.

"Looks like the site is down" means it is working

A request without a valid client certificate is refused during the TLS handshake. There is no HTTP response and no status code: the connection fails with a TLS error or a connection reset. Opening the URL in a browser looks like an outage. That is the endpoint correctly rejecting an unauthenticated client, not an outage. Do not classify handshake failures as server errors in device logs or retry them aggressively.

Download rules:

  1. Stream to a staging area, never directly over the running image. Write to a temporary name and promote it only after verification passes.
  2. Enforce size while streaming. Abort the download as soon as received bytes exceed the declared size rather than buffering an oversized body.
  3. Hash incrementally. Feed each chunk into SHA-256 as it arrives; compare the final digest against checksum (bare lowercase hex).
  4. Verify both size and checksum before promoting the file or reporting success. A mismatch on either is a FAILED, not a retry loop.
  5. Re-fetch from the start on failure. There is no resume support. The URL is stable, so a retry on a later boot fetches the same bytes.

Reporting Status

Every status update is published to the same topic, with confirmation and errors on its companions:

TopicDirectionPurpose
$aws/things/{MPBID}/jobs/{jobId}/updatePublishReport status and step
$aws/things/{MPBID}/jobs/{jobId}/update/acceptedSubscribeUpdate was recorded
$aws/things/{MPBID}/jobs/{jobId}/update/rejectedSubscribeUpdate was rejected (stale executionNumber, terminal job)

Every update echoes the executionNumber from the job listing, and all statusDetails values must be strings.

While the update runs, report the step the device is entering, so fleet monitoring can see where an update stalled and not merely that it stalled: downloadinginstalling.

A terminal update reports an outcome instead of a step. SUCCEEDED carries the version now running; FAILED and REJECTED carry a reasonCode and an optional human detail. The step is deliberately not repeated there, because every reason code already belongs to exactly one step (see the tables below). Sending both would let a device report a step and a code that contradict each other, leaving the platform to guess which one to believe.

1. Claim the job (before the download starts)

Publish to $aws/things/{MPBID}/jobs/{jobId}/update:

{
"status": "IN_PROGRESS",
"statusDetails": { "step": "downloading" },
"executionNumber": <executionNumber>
}

2. Report installing (after size and checksum verification, before flashing)

Publish to $aws/things/{MPBID}/jobs/{jobId}/update:

{
"status": "IN_PROGRESS",
"statusDetails": { "step": "installing", "bytes": "1234567", "checksum": "9f86d0…" },
"executionNumber": <executionNumber>
}

Interim IN_PROGRESS updates do not extend the in-progress timeout. Their value is diagnostic: a device that never finished downloading and a device that verified the image but died flashing it look identical without the step field.

3. Report the terminal status (after the install)

Publish to $aws/things/{MPBID}/jobs/{jobId}/update:

{
"status": "SUCCEEDED",
"statusDetails": { "version": "1.3.2" },
"executionNumber": <executionNumber>
}

version is the firmware version now running. It echoes the key the job document handed you, so a tool reports back in the vocabulary it was addressed in.

If installing requires a reboot, report SUCCEEDED after reconnecting with the new image running: the job will be waiting in inProgressJobs with its jobId and executionNumber (see Failure and Recovery).

4. Report a failure: you tried and could not finish

{
"status": "FAILED",
"statusDetails": {
"reasonCode": "checksum-mismatch",
"detail": "expected 9f86d0…, got 4a3bc1…"
},
"executionNumber": <executionNumber>
}

reasonCode is one of the values below. It is a code rather than a sentence so that failures are countable across the fleet: "41 tools failed: checksum-mismatch" is a question the platform can only answer if every device says it the same way. detail is free text for whoever debugs one specific tool, always optional — send it whenever you know something the code alone does not convey.

reasonCodeFailing stepMeaning
invalid-job-document(pre-claim)Job document failed validation. Report it without attempting the download.
tls-rejecteddownloadingThe mTLS handshake was refused (bad, expired, or missing client certificate).
download-faileddownloadingNetwork-level failure: connection reset, timeout, DNS. A reset can also be how mTLS rejection surfaces.
http-errordownloadingThe endpoint returned a non-200 HTTP status.
size-mismatchdownloadingReceived byte count differs from the declared size.
checksum-mismatchdownloadingSHA-256 of the received bytes differs from checksum.
insufficient-storagedownloadingNot enough room to stage the image.
install-failedinstallingThe image verified but could not be staged, flashed, or booted.
power-lostinstallingThe tool lost power part-way through the install and recovered.

5. Report a rejection: you looked at the job and declined it

{
"status": "REJECTED",
"statusDetails": {
"reasonCode": "low-battery",
"detail": "18% at attempt"
},
"executionNumber": <executionNumber>
}

REJECTED is not a flavour of FAILED, and the distinction is the point. Rejected means the device declined the update; failed means it tried and could not. A fleet full of rejections is a targeting problem — the wrong tools were chosen, or chosen at the wrong moment — while a fleet full of failures is a firmware problem. Folding them together hides which one you have, and they call for opposite responses: re-target, versus re-build.

Reject before doing any work. A tool that downloads an image and only then decides it was never eligible has spent exactly the bandwidth and battery that eligibility rules exist to protect.

reasonCodeMeaning
low-batteryCharge is below the threshold for a safe update.
incompatible-hardwarehardwareVersion or compatibleProductIds excludes this unit.
incompatible-versionThe device refuses this particular version transition, a downgrade for example.
updates-disabledOTA is switched off on this tool.
busyThe tool is in use and cannot interrupt what it is doing.

If none of the codes fits, send the closest one with a detail explaining the specifics, and tell the DIoTS team so the set can grow. Do not invent a code. The platform records unrecognised values rather than discarding them, precisely so a new device build can report something the cloud has not heard of yet — but that also means a typo becomes its own silent bucket in fleet reporting.

Once claimed IN_PROGRESS, the Jobs service expects a terminal status within the configured in-progress timeout (currently 60 minutes; confirm with the DIoTS team before relying on it). If the device does not report in time, the execution is marked TIMED_OUT, which the platform treats as a failure.

Delivery outcome tracking

The job execution status the device reports is what the platform monitors today; nothing else currently tells the platform that delivery landed. Report SUCCEEDED only once the image is installed, not merely downloaded.

Every terminal report is recorded against that tool's assignment for the rollout, along with the version or reasonCode it carried, and each assignment keeps the full ordered timeline of the states it passed through rather than only its latest one. So a report is not just a signal that vanishes once seen: it becomes the durable account of what this tool did with this firmware, which is what someone reads months later when asking why one tool in a fleet never updated. An execution that never reports terminal status is marked TIMED_OUT by the Jobs service after the in-progress timeout, and the platform records that as a failure with the code job-timed-out.

Independently of job status, the device keeps reporting its running firmware versions through the identity shadow; that is how the platform confirms the new image is actually live.

Failure and Recovery

Download or verification fails: report FAILED with the matching reason, delete the partial file, and stop. Do not retry indefinitely within a session; the platform monitors failures and re-rolls out as needed.

Crash after claiming IN_PROGRESS: on reboot, call GetPendingJobExecutions. The job appears in inProgressJobs with its jobId and executionNumber; no state needs to survive in NVM. If the new image is installed and running, report SUCCEEDED. If a verified image is staged but not yet installed, resume from the install step (report IN_PROGRESS with step installing first). Otherwise re-run the download from the start and report a terminal status. Worst case the in-progress timeout fires and the platform re-creates the rollout.

Job disappears between listing and describing: a newer rollout superseded it. Re-list and process whatever is queued now.

Firmware is not compatible (e.g. hardwareVersion or compatibleProductIds excludes this unit): the device is the compatibility authority. Report REJECTED with incompatible-hardware, not FAILED — nothing broke, the tool was simply never a valid target, and the platform counts those separately so the wrong targeting is visible as targeting rather than as a firmware defect.

Reference Implementation

The following script shows the complete flow: scan for a pending firmware-update job, claim it, download over mTLS, verify, install, and report each step along the way. It assumes the device is connected with its operational certificate. Adapt the MQTT and HTTP calls to your platform's libraries; the two non-negotiables are the dispatch-before-claim pattern and verify-before-promote.

import hashlib
import json
import os
import tempfile
import time
from pathlib import Path

import requests
from awscrt import mqtt
from awsiot import mqtt_connection_builder

# --- Configuration ---
DEVICE_MPBID = "FFFF000001"
MQTT_ENDPOINT = "mqtt.dev.iot.digital.milwaukeetool.com"
OPERATIONAL_CERT_PEM = "..." # Operational cert PEM; also the mTLS download identity
OPERATIONAL_KEY_PEM = "..." # Operational key PEM
STAGING_DIR = Path("firmware-staging")
RESPONSE_TIMEOUT_SECS = 15 # increase for cellular or high-latency links (60+ recommended)
CHUNK_SIZE = 64 * 1024

REQUIRED_FIELDS = ("downloadUrl", "checksum", "size", "version", "rolloutId")


def wait_for(response: dict, operation: str):
for _ in range(RESPONSE_TIMEOUT_SECS):
if response["error"]:
raise response["error"]
if response["data"]:
return response["data"]
time.sleep(1)
raise TimeoutError(f"Timed out waiting for {operation}")


def update_job(conn: mqtt.Connection, job_id: str, execution_number: int,
status: str, status_details: dict):
conn.publish(
topic=f"$aws/things/{DEVICE_MPBID}/jobs/{job_id}/update",
payload=json.dumps({
"status": status,
"statusDetails": status_details, # values must be strings
"executionNumber": execution_number,
}).encode(),
qos=mqtt.QoS.AT_LEAST_ONCE,
)[0].result()


def download_and_verify(doc: dict) -> tuple:
"""Download the image over mTLS and verify it.

Returns ("", "") on success, otherwise (reasonCode, detail) where the code is
from the vocabulary above and the detail is optional free text."""
STAGING_DIR.mkdir(parents=True, exist_ok=True)
filename = doc["downloadUrl"].rsplit("/", 1)[-1] or "firmware.bin"
final_path = STAGING_DIR / filename
part_path = STAGING_DIR / (filename + ".part")

# requests needs the client cert and key as file paths
cert_file = tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False)
key_file = tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False)
try:
cert_file.write(OPERATIONAL_CERT_PEM); cert_file.close()
key_file.write(OPERATIONAL_KEY_PEM); key_file.close()

sha256 = hashlib.sha256()
received = 0
try:
with requests.get(
doc["downloadUrl"],
cert=(cert_file.name, key_file.name),
stream=True,
timeout=(10, 60), # connect, read
) as response:
if response.status_code != 200:
return "http-error", f"HTTP {response.status_code}"
with open(part_path, "wb") as out:
for chunk in response.iter_content(CHUNK_SIZE):
received += len(chunk)
if received > doc["size"]: # abort mid-stream
return "size-mismatch", f"exceeded {doc['size']} bytes"
sha256.update(chunk)
out.write(chunk)
except requests.exceptions.SSLError as error:
return "tls-rejected", str(error)[:256] # mTLS refused at the handshake
except requests.exceptions.RequestException as error:
# A connection reset can also be how mTLS rejection surfaces.
return "download-failed", str(error)[:256]

if received != doc["size"]:
return "size-mismatch", f"expected {doc['size']}, got {received}"
if sha256.hexdigest() != doc["checksum"]:
return "checksum-mismatch", f"expected {doc['checksum'][:12]}…"

part_path.replace(final_path) # promote only after both checks pass
return "", ""
finally:
part_path.unlink(missing_ok=True) # no failure leaves a file at the final path
os.unlink(cert_file.name)
os.unlink(key_file.name)


# --- Step 1: List pending jobs without claiming any of them ---
conn = mqtt_connection_builder.mtls_from_bytes(
endpoint=MQTT_ENDPOINT,
cert_bytes=OPERATIONAL_CERT_PEM.encode(),
pri_key_bytes=OPERATIONAL_KEY_PEM.encode(),
client_id=DEVICE_MPBID,
clean_session=False,
keep_alive_secs=30,
)
conn.connect().result()

pending_response = {"data": None, "error": None}

conn.subscribe(
topic=f"$aws/things/{DEVICE_MPBID}/jobs/get/accepted",
qos=mqtt.QoS.AT_LEAST_ONCE,
callback=lambda topic, payload, **kw: pending_response.update({"data": json.loads(payload)}),
)[0].result()

conn.subscribe(
topic=f"$aws/things/{DEVICE_MPBID}/jobs/get/rejected",
qos=mqtt.QoS.AT_LEAST_ONCE,
callback=lambda topic, payload, **kw: pending_response.update(
{"error": Exception(f"jobs/get rejected: {payload}")}
),
)[0].result()

conn.publish(
topic=f"$aws/things/{DEVICE_MPBID}/jobs/get",
payload=json.dumps({}).encode(),
qos=mqtt.QoS.AT_LEAST_ONCE,
)[0].result()

pending = wait_for(pending_response, "GetPendingJobExecutions")
all_jobs = pending.get("queuedJobs", []) + pending.get("inProgressJobs", [])

# --- Step 2: Describe each job and find the firmware-update job ---
fw_job_id = None
fw_exec_number = None
fw_doc = None

for summary in all_jobs:
job_id = summary["jobId"]
exec_number = summary["executionNumber"]

describe_response = {"data": None, "error": None}

conn.subscribe(
topic=f"$aws/things/{DEVICE_MPBID}/jobs/{job_id}/get/accepted",
qos=mqtt.QoS.AT_LEAST_ONCE,
callback=lambda topic, payload, **kw: describe_response.update({"data": json.loads(payload)}),
)[0].result()

conn.subscribe(
topic=f"$aws/things/{DEVICE_MPBID}/jobs/{job_id}/get/rejected",
qos=mqtt.QoS.AT_LEAST_ONCE,
callback=lambda topic, payload, _jid=job_id, **kw: describe_response.update(
{"error": Exception(f"jobs/{_jid}/get rejected: {payload}")}
),
)[0].result()

conn.publish(
topic=f"$aws/things/{DEVICE_MPBID}/jobs/{job_id}/get",
payload=json.dumps({"includeJobDocument": True}).encode(),
qos=mqtt.QoS.AT_LEAST_ONCE,
)[0].result()

described = wait_for(describe_response, f"DescribeJobExecution({job_id})")
document = described.get("execution", {}).get("jobDocument", {})

if document.get("operation") == "firmware-update":
fw_job_id = job_id
fw_exec_number = exec_number
fw_doc = document
break
# Other job types (e.g. rotate-certificate) are left for their own handlers.

if fw_job_id is None:
conn.disconnect().result()
raise SystemExit("No firmware update job found in pending queue")

# --- Step 3: Validate the document BEFORE claiming ---
if any(not fw_doc.get(field) for field in REQUIRED_FIELDS):
update_job(conn, fw_job_id, fw_exec_number, "FAILED",
{"reasonCode": "invalid-job-document",
"detail": "missing required field"})
conn.disconnect().result()
raise SystemExit("Invalid job document")

# --- Step 4: Claim, download, verify, install, report each step ---
update_job(conn, fw_job_id, fw_exec_number, "IN_PROGRESS", {"step": "downloading"})

failure_code, failure_detail = download_and_verify(fw_doc)

if failure_code:
# A terminal update reports the outcome, not the step: the code already
# says which step it belongs to. detail is optional; send it when known.
details = {"reasonCode": failure_code}
if failure_detail:
details["detail"] = failure_detail
update_job(conn, fw_job_id, fw_exec_number, "FAILED", details)
print(f"Firmware update failed: {failure_code}")
else:
update_job(conn, fw_job_id, fw_exec_number, "IN_PROGRESS", {
"step": "installing",
"bytes": str(fw_doc["size"]),
"checksum": fw_doc["checksum"],
})

# Stage and flash the image here, per your device's own update policy.
# If installing requires a reboot, skip the terminal update below and report
# SUCCEEDED after reconnecting with the new image running: the job will be
# waiting in inProgressJobs (see Failure and Recovery).
install_failed = False # replace with the real outcome of the install step

if install_failed:
update_job(conn, fw_job_id, fw_exec_number, "FAILED",
{"reasonCode": "install-failed"})
print("Firmware install failed")
else:
update_job(conn, fw_job_id, fw_exec_number, "SUCCEEDED",
{"version": fw_doc["version"]})
print(f"Firmware {fw_doc['version']} installed")

conn.disconnect().result()

AWS Documentation